package cuegen

import (
	"fmt"
	"reflect"
	"slices"

	"cuelang.org/go/cue/ast"
	"cuelang.org/go/cue/parser"
	"cuelang.org/go/cue/token"

	"encr.dev/v2/app"
	"encr.dev/v2/internals/perr"
	"encr.dev/v2/internals/schema"
	"encr.dev/v2/internals/schema/schemautil"
)

// service represents the single generated file we will create for a service
// from all of it's config.Load calls
type serviceFile struct {
	errs           *perr.List
	g              *Generator
	svc            *app.Service
	file           *ast.File
	neededImports  map[string]string // map of package path to name
	topLevelFields []any
	fieldLookup    map[string]*ast.Field

	typeUsage *definitionGenerator
}

// countNamedUsagesAndCollectImports counts the number of times a named type is used in the service
func (s *serviceFile) countNamedUsagesAndCollectImports(typ schema.Type) {
	var processType func(typ schema.Type)
	processType = func(typ schema.Type) {
		switch typ := typ.(type) {
		case schema.NamedType:
			if unwrapped, wasConfig := unwrapConfig(s.errs, typ); wasConfig {
				processType(unwrapped)
			} else {
				s.typeUsage.Inc(typ)
			}

		case schema.BuiltinType:
			if typ.Kind == schema.Time {
				s.neededImports["time"] = "time"
			}
		}
	}

	schemautil.Walk(typ, func(typ schema.Type) bool {
		processType(typ)
		return true
	})
}

func (s *serviceFile) registerTopLevelField(typ schema.Type) {
	st, ok := schemautil.ResolveNamedStruct(typ, false)
	if !ok {
		s.errs.Add(errNotNamedStruct.AtGoNode(typ.ASTExpr()))
		return
	}
	concrete := schemautil.ConcretizeWithTypeArgs(s.errs, st.Decl.Type, st.TypeArgs)

	fields := s.structToFields(concrete.(schema.StructType))

	for _, field := range fields {
		f := field.CUE
		name, _, err := ast.LabelName(f.Label)
		if err != nil {
			s.errs.Add(errInvalidFieldLabel.AtGoNode(field.Go.AST).Wrapping(err))
			continue
		}

		// If we already know about the field, merge the definitions
		// (this could be the case from multiple calls to `config.Load` inside
		// the same service to either the same or different struct types
		// with both have the same field name
		if existing, found := s.fieldLookup[name]; found {
			if len(f.Comments()) > 0 {
				if len(existing.Comments()) == 0 {
					existing.SetComments(f.Comments())
				} else {
					existingCommentGrp := existing.Comments()[0]
					for _, comment := range f.Comments() {
						if !commentAlreadyPresent(existing, comment) {
							existingCommentGrp.List = append(existingCommentGrp.List, comment.List...)
						}
					}

					// If after this check the existing comment group is now multiline, we need to move the comment groups
					// position to be before the field label.
					if len(existingCommentGrp.List) > 0 {
						existingCommentGrp.Position = 0
						existingCommentGrp.List[0].Slash = token.NewSection.Pos()
					}
				}
			}

			// Merge the values if they are different
			if !reflect.DeepEqual(existing.Value, f.Value) {
				existing.Value = ast.NewBinExpr(token.AND, existing.Value, f.Value)
			}
		} else {
			// otherwise add this field
			s.fieldLookup[name] = f
			s.topLevelFields = append(s.topLevelFields, f)
		}
	}
}

func (s *serviceFile) generateCue() {
	// If there are no top level fields, we've got nothing to do here
	if len(s.topLevelFields) == 0 {
		return
	}

	// Add the package name and description comment
	pkg := &ast.Package{Name: ast.NewIdent(s.svc.Name)}
	s.file.Decls = append(s.file.Decls, pkg)
	s.file.AddComment(&ast.CommentGroup{
		List: []*ast.Comment{
			{Text: "// Code generated by encore. DO NOT EDIT."},
			{Text: "//"},
			{Text: "// The contents of this file are generated from the structs used in"},
			{Text: "// conjunction with Encore's `config.Load[T]()` function. This file"},
			{Text: "// automatically be regenerated if the data types within the struct"},
			{Text: "// are changed."},
			{Text: "//"},
			{Text: "// For more information about this file, see:"},
			{Text: "// https://encore.dev/docs/develop/config"},
		},
	})

	s.generateEnvironmentalDefinitions()

	// Add any missing imports
	if len(s.neededImports) > 0 {
		// Get an ordered list of the imports
		imports := make([]string, 0, len(s.neededImports))
		for pkg := range s.neededImports {
			imports = append(imports, pkg)
		}
		slices.Sort(imports)

		// Create all the import specs
		for _, importPath := range imports {
			var ident *ast.Ident = nil
			if s.neededImports[importPath] != importPath {
				ident = ast.NewIdent(s.neededImports[importPath])
			}

			spec := ast.NewImport(ident, importPath)
			s.file.Imports = append(s.file.Imports, spec)
		}

		// Now add the import statement
		s.file.Decls = append(s.file.Decls, &ast.ImportDecl{
			Specs: s.file.Imports,
		})
	}

	// Now write the top level fields required in the config
	appConfigStruct := &ast.Field{
		Label: ast.NewIdent("#Config"),
		Value: ast.NewStruct(s.topLevelFields...),
	}
	appConfigStruct.AddComment(&ast.CommentGroup{
		List: []*ast.Comment{
			{Text: "// #Config is the top level configuration for the application and is generated"},
			{Text: "// from the Go types you've passed into `config.Load[T]()`. Encore uses a definition"},
			{Text: "// of this struct which is closed, such that the CUE tooling can any typos of field names."},
			{Text: "// this definition is then immediately inlined, so any fields within it are expected"},
			{Text: "// as fields at the package level."},
		},
	})
	s.file.Decls = append(s.file.Decls, appConfigStruct, ast.NewIdent("#Config"))

	// Write any declarations we've used multiple times to the file
	for _, named := range s.typeUsage.NamesWithCountsOver(1) {
		concrete := schemautil.ConcretizeWithTypeArgs(s.errs, named.Decl().Type, named.TypeArgs)
		defIdent := s.typeUsage.CueIdent(named)
		fieldType := s.toCueType(concrete)

		field := &ast.Field{
			Label: defIdent,
			Value: fieldType,
		}
		if named.DeclInfo.Doc != "" {
			addCommentToField(field, named.DeclInfo.Doc)
		} else {
			// If there isn't a doc, we want to force a new section
			// above the name (empty line above).
			// The doc block will add this for us
			defIdent.NamePos = token.NewSection.Pos()
		}
		s.file.Decls = append(s.file.Decls, field)
	}
}

type structField struct {
	Go  schema.StructField
	CUE *ast.Field
}

// structToFields converts a struct to a list of fields which can then be
// either included in a definition, a line struct or the file level declarations
func (s *serviceFile) structToFields(st schema.StructType) []structField {
	var fields []structField

	for _, f := range st.Fields {
		if f.IsAnonymous() {
			// TODO error?
			continue
		}
		isOptional := false

		// Convert the type to CUE
		field := &ast.Field{
			Label: ast.NewIdent(f.Name.MustGet()),
			Value: s.toCueType(f.Type),
		}

		for _, tag := range f.Tag.Tags() {
			if tag.Key == "json" {
				if tag.Name != "" {
					field.Label = ast.NewIdent(tag.Name)
				}
				for _, option := range tag.Options {
					if option == "omitempty" {
						isOptional = true
					}
				}
			}

			if tag.Key == "cue" {
				if tag.Name != "" {
					expr, err := parser.ParseExpr("encore struct", tag.Name)
					if err != nil {
						s.errs.Add(errInvalidCUEExpr.AtGoNode(f.AST).Wrapping(err))
						continue
					}
					field.Value = ast.NewBinExpr(token.AND, field.Value, expr)
				}
				for _, option := range tag.Options {
					if option == "opt" {
						isOptional = true
					}
				}
			}
		}

		// Mark the field as optional if it is
		if isOptional {
			field.Optional = token.Blank.Pos()
		}

		// Add the documentation to the field
		if f.Doc != "" {
			addCommentToField(field, f.Doc)
		}

		fields = append(fields, structField{Go: f, CUE: field})
	}

	return fields
}

// Convert a schema type into a cue type
func (s *serviceFile) toCueType(unknownType schema.Type) ast.Expr {
	switch typ := unknownType.(type) {
	case schema.NamedType:
		if underlying, wasConfig := unwrapConfig(s.errs, typ); wasConfig {
			return s.toCueType(underlying)
		}

		usageCount := s.typeUsage.Count(typ)
		if usageCount <= 1 {
			// inline the type if it's only used once
			concrete := schemautil.ConcretizeWithTypeArgs(s.errs, typ.Decl().Type, typ.TypeArgs)
			return s.toCueType(concrete)
		} else {
			return s.typeUsage.CueIdent(typ)
		}
	case schema.StructType:
		fields := s.structToFields(typ)
		fieldsInterface := make([]any, len(fields))
		for i, field := range fields {
			fieldsInterface[i] = field.CUE
		}

		return ast.NewStruct(fieldsInterface...)
	case schema.MapType:
		keyType := s.toCueType(typ.Key)
		valueType := s.toCueType(typ.Value)
		return ast.NewStruct(ast.NewList(keyType), valueType)

	case schema.ListType:
		listType := s.toCueType(typ.Elem)
		return ast.NewList(&ast.Ellipsis{Type: listType})

	case schema.BuiltinType:
		return s.builtinToCue(typ.Kind)

	case schema.PointerType:
		// Pointers are not supported in CUE, so we just convert the underlying type.
		return s.toCueType(typ.Elem)

	default:
		// TODO error instead
		panic(fmt.Sprintf("unexpected type: %T", typ))
	}
}

func (s *serviceFile) builtinToCue(kind schema.BuiltinKind) ast.Expr {
	switch kind {
	case schema.Any:
		return ast.NewIdent("_") // top
	case schema.Bool:
		return ast.NewIdent("bool")
	case schema.Int8:
		return ast.NewIdent("int8")
	case schema.Int16:
		return ast.NewIdent("int16")
	case schema.Int32:
		return ast.NewIdent("int32")
	case schema.Int64:
		return ast.NewIdent("int64")
	case schema.Uint8:
		return ast.NewIdent("uint8")
	case schema.Uint16:
		return ast.NewIdent("uint16")
	case schema.Uint32:
		return ast.NewIdent("uint32")
	case schema.Uint64:
		return ast.NewIdent("uint64")
	case schema.Float32:
		return ast.NewIdent("float32")
	case schema.Float64:
		return ast.NewIdent("float64")
	case schema.String:
		return ast.NewIdent("string")
	case schema.Bytes:
		return ast.NewIdent("bytes")
	case schema.Time:
		return ast.NewSel(ast.NewIdent("time"), "Time")
	case schema.UUID:
		return ast.NewIdent("string")
	case schema.JSON:
		return ast.NewIdent("string")
	case schema.UserID:
		return ast.NewIdent("string")
	case schema.Int:
		return ast.NewIdent("int")
	case schema.Uint:
		return ast.NewIdent("uint")
	default:
		// TODO error instead
		panic(fmt.Sprintf("unknown builtin: %s", kind))
	}
}

func (s *serviceFile) generateEnvironmentalDefinitions() {
	appMetadata := createStruct(
		"#Meta",
		createTaggedDefinition(
			"APIBaseURL", "APIBaseURL",
			"The base URL which can be used to call the API of this running application.",
			ast.NewIdent("string"),
		),
		createStruct(
			"Environment",
			createTaggedDefinition(
				"Name", "EnvName",
				"The name of this environment",
				ast.NewIdent("string"),
			),
			createTaggedEnumDefinition(
				"Type", "EnvType",
				"The type of environment that the application is running in",
				"production", "development", "ephemeral", "test",
			),
			createTaggedEnumDefinition(
				"Cloud", "CloudType",
				"The cloud provider that the application is running in",
				"aws", "azure", "gcp", "encore", "local",
			),
		),
	)

	appMetadata.AddComment(&ast.CommentGroup{
		List: []*ast.Comment{
			{Text: "// #Meta contains metadata about the running Encore application."},
			{Text: "// The values in this struct will be injected by Encore upon deployment and can be"},
			{Text: "// referenced from other config values for example when configuring a callback URL:"},
			{Text: "//    CallbackURL: \"\\(#Meta.APIBaseURL)/webhooks.Handle`\""},
		},
	})

	s.file.Decls = append(s.file.Decls, appMetadata)
}

func createStruct(name string, fields ...any) *ast.Field {
	return &ast.Field{
		Label: ast.NewIdent(name),
		Value: ast.NewStruct(fields...),
	}
}

func createTaggedEnumDefinition(name string, tagName string, comment string, options ...string) *ast.Field {
	set := make([]ast.Expr, len(options))
	for i, key := range options {
		set[i] = ast.NewString(key)
	}

	return createTaggedDefinition(
		name, tagName,
		comment,
		ast.NewBinExpr(token.OR, set...),
	)
}

func createTaggedDefinition(name string, tagName string, comment string, value ast.Expr) *ast.Field {
	field := &ast.Field{
		Label: ast.NewIdent(name),
		Value: value,
		Attrs: []*ast.Attribute{
			{
				At: token.NoPos,
				Text: fmt.Sprintf(
					"@tag(%s)",
					tagName,
				),
			},
		},
	}

	field.AddComment(&ast.CommentGroup{
		Position: 4,
		List: []*ast.Comment{
			{
				Slash: token.NoPos,
				Text:  fmt.Sprintf("// %s", comment),
			},
		},
	})

	return field
}

func unwrapConfig(errs *perr.List, typ schema.Type) (unwrapped schema.Type, wasConfig bool) {
	if named, ok := typ.(schema.NamedType); ok {
		if underlying, isList, isConfig := schemautil.UnwrapConfigType(errs, named); isConfig {
			if isList {
				underlying = schema.ListType{Elem: underlying}
			}
			return underlying, true
		}
	}
	return typ, false
}
